TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import Link from 'next/link';2import { notFound } from 'next/navigation';3import { requireAdmin } from '@/lib/admin/auth';4import { connectorDetail } from '@/lib/admin/queries';5import { connectorAction, updateConnectorConfig } from '@/lib/admin/actions';6import { AdminShell, ActionButton, StatusPill, Kpi, fmtTs, n } from '@/components/admin/shell';7import { HealthLabel, SourceLogo, Progress, Chip, Json } from '@/components/admin/connector-bits';8import { Table, th, td, tdNum } from '@/components/ui/primitives';910export default async function ConnectorInspectPage({ params }: { params: Promise<{ id: string }> }) {11 await requireAdmin();12 const { id } = await params;13 const d = await connectorDetail(id);14 if (!d) notFound();15 const c = d.connector;16 const health = (d.health?.health ?? {}) as Record<string, unknown>;17 const outputs = (d.outputs ?? {}) as Record<string, unknown>;18 const meta = (c.meta ?? {}) as Record<string, unknown>;19 const activeBackfill = d.backfills.find((b) => b.status === 'running' || b.status === 'paused');20 const fieldStats = d.fieldStats as Array<{ field: string; day: string; total: number; nulls: number }>;21 const today = new Date().toISOString().slice(0, 10);22 const nullRates = Object.values(23 fieldStats.reduce<Record<string, { field: string; today: [number, number]; base: [number, number] }>>((acc, r) => {24 const e = (acc[r.field] ??= { field: r.field, today: [0, 0], base: [0, 0] });25 const t = r.day === today ? e.today : e.base;26 t[0] += Number(r.total);27 t[1] += Number(r.nulls);28 return acc;29 }, {}),30 );31 const actions = [32 ['probe', 'Test', 'neutral'],33 ['run', 'Run now', 'neutral'],34 ['backfill', activeBackfill ? 'Continue backfill' : 'Backfill', 'neutral'],35 ...(activeBackfill?.status === 'running' ? [['backfill_pause', 'Pause backfill', 'neutral']] : []),36 ...(activeBackfill ? [['backfill_reset', 'Reset backfill', 'danger']] : []),37 ['retry', 'Retry from cursor', 'neutral'],38 c.status === 'active' ? ['pause', 'Pause', 'danger'] : ['resume', 'Resume', 'primary'],39 c.status === 'maintenance' ? ['resume', 'End maintenance', 'primary'] : ['maintenance', 'Maintenance', 'neutral'],40 ] as const;41 return (42 <AdminShell43 current="/admin/connectors"44 title={String(c.display_name)}45 subtitle={46 <>47 <SourceLogo domain={String(meta.domain ?? '')} name={String(c.display_name)} /> {String(c.id)} · source <span className="font-mono">{String(c.source_id)}</span> · {String(meta.domain ?? '')} · {String(meta.country ?? (c.regions as string[])?.[0] ?? 'global')} · {(c.engine_priority as string[]).join(' → ')} · {String(meta.acquisitionMethod ?? '')} · categories {(c.categories as string[]).join(', ')} · <Link href="/admin/connectors" className="underline">back</Link>48 </>49 }50 actions={51 <div className="flex flex-wrap gap-1">52 {actions.map(([a, label, tone]) => (53 <form key={a} action={connectorAction}>54 <input type="hidden" name="id" value={String(c.id)} />55 <input type="hidden" name="action" value={a} />56 <ActionButton label={label} small={false} tone={tone as 'neutral' | 'danger' | 'primary'} />57 </form>58 ))}59 </div>60 }61 >62 <div className="mb-3 flex flex-wrap gap-1">63 {((meta.capabilities as string[]) ?? []).map((cap) => (64 <Chip key={cap} tone="index">{cap}</Chip>65 ))}66 {((meta.requires as string[]) ?? []).map((r) => (67 <Chip key={r} tone="alert">requires {r}</Chip>68 ))}69 <Chip>refresh {String(meta.refreshClass ?? '')} · {n(c.refresh_frequency_minutes)} min</Chip>70 <Chip>history {String(meta.historicalDepth ?? 'none')}</Chip>71 {meta.termsUrl ? (72 <a href={String(meta.termsUrl)} target="_blank" rel="noreferrer noopener" className="text-[10px] underline text-subtle">terms</a>73 ) : null}74 </div>75 <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">76 <Kpi label="Status" value={<StatusPill status={String(c.status)} />} sub={<>health <HealthLabel status={(d.health?.status as string) ?? 'unknown'} /></>} />77 <Kpi label="Success 24h" value={health.success_rate_24h != null ? `${Math.round(Number(health.success_rate_24h) * 100)}%` : '—'} sub={`${n(health.pages_success)}/${n(health.pages_attempted)} pages`} />78 <Kpi label="Firecrawl / Scrapfly" value={`${health.firecrawl_success_rate != null ? Math.round(Number(health.firecrawl_success_rate) * 100) + '%' : '—'} / ${health.scrapfly_fallback_rate != null ? Math.round(Number(health.scrapfly_fallback_rate) * 100) + '%' : '—'}`} sub="success / fallback share" />79 <Kpi label="Parse failures" value={health.parse_failure_rate != null ? `${(Number(health.parse_failure_rate) * 100).toFixed(1)}%` : '—'} sub={`${n(health.duplicates_24h)} duplicates 24h`} />80 <Kpi label="Outputs" value={`${n(outputs.sales)} sales`} sub={`${n(outputs.listings)} listings · ${n(outputs.observations)} observations`} />81 <Kpi label="Last success" value={fmtTs(c.last_success_at).slice(0, 16)} sub={`next ${fmtTs(c.next_run_at).slice(0, 16)} · fresh ${health.data_freshness ? fmtTs(health.data_freshness).slice(0, 10) : '—'}`} />82 <Kpi label="Requests 24h" value={n(health.requests_24h)} sub={`${health.latency_ms_avg != null ? n(health.latency_ms_avg) + ' ms avg' : '—'} · ${n(health.challenges_24h)} challenges · ${n(health.rate_limited_24h)}× 429`} tone={Number(health.challenges_24h) > 0 ? 'alert' : undefined} />83 <Kpi label="HTTP errors 24h" value={Object.entries((health.http_errors as Record<string, number>) ?? {}).map(([k, v]) => `${k}×${v}`).join(' ') || '0'} sub={`${n(health.circuit_refusals_24h)} refused by circuit`} tone={Object.keys((health.http_errors as Record<string, number>) ?? {}).length ? 'loss' : undefined} />84 <Kpi label="Volume baseline" value={health.records_7d_daily_avg != null ? `${n(health.records_7d_daily_avg)}/day` : '—'} sub={`${n(health.records_24h)} raw in 24h`} tone={((health.anomalies as string[]) ?? []).some((a) => a.startsWith('result_count_collapse')) ? 'alert' : undefined} />85 <Kpi label="Certificates" value={n(outputs.certificates)} sub="cert numbers seen via this source" />86 </div>8788 {activeBackfill || d.backfills.length ? (89 <section className="card mt-6">90 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Historical backfill campaigns</h2></header>91 <Table>92 <thead><tr><th className={th}>Started</th><th className={th}>Status</th><th className={th}>Progress</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Items</th><th className={th}>Reached date</th><th className={`${th} text-right`}>Runs</th><th className={`${th} text-right`}>Errors</th><th className={th}>Last cursor</th><th className={th}>Last error</th></tr></thead>93 <tbody>94 {d.backfills.map((b) => (95 <tr key={String(b.id)}>96 <td className={td}>{fmtTs(b.started_at)}</td>97 <td className={td}><StatusPill status={String(b.status)} /></td>98 <td className={td}><Progress percent={b.percent as number | null} status={String(b.status)} /></td>99 <td className={tdNum}>{n(b.pages_processed)}{b.total_pages ? ` / ${n(b.total_pages)}` : ''}</td>100 <td className={tdNum}>{n(b.items_processed)}</td>101 <td className={td}>{b.reached_date ? String(b.reached_date) : '—'}</td>102 <td className={tdNum}>{n(b.runs)}</td>103 <td className={tdNum}>{n(b.errors)}</td>104 <td className={`${td} max-w-[260px] truncate font-mono text-[10px]`} title={JSON.stringify(b.last_cursor)}>{JSON.stringify(b.last_cursor)}</td>105 <td className={`${td} max-w-[220px] truncate text-loss`} title={String(b.last_error ?? '')}>{String(b.last_error ?? '')}</td>106 </tr>107 ))}108 </tbody>109 </Table>110 </section>111 ) : null}112113 {nullRates.length ? (114 <section className="card mt-6">115 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Field presence (schema-drift monitor)</h2></header>116 <div className="grid grid-cols-2 gap-x-6 gap-y-1 px-4 py-3 text-xs md:grid-cols-4 xl:grid-cols-6">117 {nullRates.map((f) => {118 const tRate = f.today[0] ? f.today[1] / f.today[0] : null;119 const bRate = f.base[0] ? f.base[1] / f.base[0] : null;120 const drift = tRate !== null && bRate !== null && tRate - bRate >= 0.3;121 return (122 <div key={f.field} className={drift ? 'text-alert' : ''}>123 <span className="font-mono">{f.field}</span> <span className="text-subtle">null today {tRate === null ? '—' : `${Math.round(tRate * 100)}%`} · 7d {bRate === null ? '—' : `${Math.round(bRate * 100)}%`}</span>124 </div>125 );126 })}127 </div>128 </section>129 ) : null}130131 {d.anomalies.length ? (132 <section className="card mt-6">133 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold text-alert">Anomalies (recent runs)</h2></header>134 <ul className="divide-y divide-border px-4 text-xs">{d.anomalies.map((a, i) => <li key={i} className="py-1.5 font-mono">{a}</li>)}</ul>135 </section>136 ) : null}137138 <section className="card mt-6">139 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Runs</h2></header>140 <Table>141 <thead><tr><th className={th}>Started</th><th className={th}>Status</th><th className={th}>Trigger</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Raw</th><th className={`${th} text-right`}>Norm.</th><th className={`${th} text-right`}>Dup.</th><th className={`${th} text-right`}>Rej.</th><th className={`${th} text-right`}>Credits</th><th className={th}>Engines</th><th className={th}>Error</th></tr></thead>142 <tbody>143 {d.runs.length === 0 ? <tr><td className={td} colSpan={11}><span className="text-subtle">No runs yet.</span></td></tr> : null}144 {d.runs.map((r) => (145 <tr key={String(r.id)}>146 <td className={td}>{fmtTs(r.started_at)}<div className="text-[10px] text-subtle">{r.finished_at ? `${Math.round((new Date(String(r.finished_at)).getTime() - new Date(String(r.started_at)).getTime()) / 1000)} s` : 'running'}</div></td>147 <td className={td}><StatusPill status={String(r.status)} /></td>148 <td className={td}>{String(r.trigger)}</td>149 <td className={tdNum}>{n(r.pages_success)}/{n(r.pages_attempted)}</td>150 <td className={tdNum}>{n(r.records_raw)}</td>151 <td className={tdNum}>{n(r.records_normalized)}</td>152 <td className={tdNum}>{n(r.records_duplicate)}</td>153 <td className={tdNum}>{n(r.records_rejected)}</td>154 <td className={tdNum}>{n(r.cost_credits, 1)}</td>155 <td className={`${td} font-mono text-[10px]`}>{Object.entries((r.engine_stats ?? {}) as Record<string, { attempts: number; success: number }>).map(([k, v]) => `${k} ${v.success}/${v.attempts}`).join(' · ')}</td>156 <td className={`${td} max-w-[260px] truncate text-loss`} title={String(r.error ?? '')}>{r.error ? String(r.error) : ''}</td>157 </tr>158 ))}159 </tbody>160 </Table>161 </section>162163 <div className="mt-6 grid gap-6 lg:grid-cols-2">164 <section className="card">165 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Raw records (latest)</h2></header>166 <ul className="divide-y divide-border text-xs">167 {d.rawSample.length === 0 ? <li className="px-4 py-3 text-subtle">None yet.</li> : null}168 {d.rawSample.map((r) => (169 <li key={String(r.id)} className="px-4 py-2">170 <div className="flex items-center justify-between gap-2"><span className="font-mono text-[11px]">{String(r.kind)} · {String(r.engine)} · {String(r.http_status ?? '')}</span><span className="text-subtle">{fmtTs(r.fetched_at)}</span></div>171 <a href={String(r.url)} target="_blank" rel="noreferrer noopener" className="block truncate text-muted hover:text-fg">{String(r.url)}</a>172 <div className="flex items-center justify-between">173 {r.process_error ? <p className="text-loss">{String(r.process_error)}</p> : <p className="text-[10px] text-subtle">{r.processed_at ? `processed ${fmtTs(r.processed_at)}` : 'unprocessed'}</p>}174 <Link href={`/admin/connectors/${String(c.id)}/raw/${String(r.id)}`} className="text-[10px] underline">view payload</Link>175 </div>176 </li>177 ))}178 </ul>179 </section>180 <section className="card">181 <header className="flex items-center justify-between border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Normalized records (latest)</h2><span className="text-[11px] text-muted">{d.normalized.map((x) => `${x.status} ${n(x.n)}`).join(' · ')}</span></header>182 <ul className="divide-y divide-border text-xs">183 {d.normalizedSample.length === 0 ? <li className="px-4 py-3 text-subtle">None yet.</li> : null}184 {d.normalizedSample.map((r) => (185 <li key={String(r.id)} className="px-4 py-2">186 <div className="flex items-center justify-between gap-2"><span className="truncate">{String(r.raw_title ?? '')}</span><StatusPill status={String(r.status)} /></div>187 <div className="flex items-center justify-between">188 <p className="text-[10px] text-subtle">{String(r.kind)} · {r.price ? `${String(r.price)} ${String(r.currency ?? '')}` : ''} · {r.match_method ? `${String(r.match_method)} ${r.match_confidence ? Math.round(Number(r.match_confidence) * 100) + '%' : ''}` : ''} {r.reject_reason ? `· ${String(r.reject_reason)}` : ''}</p>189 <Link href={`/admin/connectors/${String(c.id)}/record/${String(r.id)}`} className="text-[10px] underline">view record</Link>190 </div>191 </li>192 ))}193 </ul>194 </section>195 </div>196197 <div className="mt-6 grid gap-6 lg:grid-cols-2">198 <section className="card">199 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Costs (30d)</h2></header>200 <Table>201 <thead><tr><th className={th}>Day</th><th className={th}>Kind</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD est.</th></tr></thead>202 <tbody>203 {d.costs.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">No cost events.</span></td></tr> : null}204 {d.costs.map((r, i) => <tr key={i}><td className={td}>{String(r.day)}</td><td className={td}>{String(r.kind)}</td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td></tr>)}205 </tbody>206 </Table>207 </section>208 <section className="card">209 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Mapping & schedule</h2></header>210 <form action={updateConnectorConfig} className="space-y-3 p-4 text-xs">211 <input type="hidden" name="id" value={String(c.id)} />212 <div className="grid grid-cols-2 gap-3">213 <label className="block text-muted">Refresh (minutes)<input name="refresh" type="number" min={5} defaultValue={Number(c.refresh_frequency_minutes)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm" /></label>214 <label className="block text-muted">Priority215 <select name="priority" defaultValue={String(c.priority)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm"><option value="high">high</option><option value="medium">medium</option><option value="low">low</option></select>216 </label>217 </div>218 <label className="block text-muted">Config JSON (seeds, mappings, selectors — connector-specific)219 <textarea name="config" rows={10} defaultValue={JSON.stringify(c.config ?? {}, null, 2)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 font-mono text-[11px]" />220 </label>221 <div className="flex items-center justify-between"><span className="text-subtle">schema v{String(c.schema_version)} · connector v{String(c.connector_version)} · source trust {d.source ? Math.round(Number(d.source.trust_score) * 100) + '%' : '—'}</span><ActionButton label="Save mapping" small={false} tone="primary" /></div>222 </form>223 </section>224 </div>225226 <section className="card mt-6">227 <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Access & compliance notes (meta.json)</h2></header>228 <div className="px-4 py-3 text-xs leading-relaxed text-muted">{String(meta.accessNotes ?? 'No accessNotes recorded — add them to meta.json (SPEC §36).')}</div>229 <details className="border-t border-border px-4 py-2 text-xs"><summary className="cursor-pointer text-subtle">registry entry</summary><Json value={meta} className="mt-2" /></details>230 </section>231 </AdminShell>232 );233}234